Cython后再稍微整整Pyc字节码文件逆向,然后开始搞Go和Rust。
Pyc字节码分析 1
测试环境
不同版本,pyc文件的结构组织,opcode都有所不同。我准备多个大版本都分析一下。
1 | 3.10.2 win安装包+源码 |
同时下载安装包和源码进行分析。
python2就不管了,已经过时了。
总览
- pyc文件结构分析
- pyc虚拟机汇编指令分析
- pyc字节码混淆与反混淆(可能)
- pyinstaller程序解包
- py脚本文件混淆
值得注意的是,GitHub上面有pyc反编译工具,但在我上次确认的时候,只支持3.8及以下版本,3.9以上不行。所以3.9以上版本的逆向需要着重注意。
网上查了一下其实有不少pyc逆向的深入解析,但是不少都是18,19年的干货了,版本也局限于python2,所以我准备稍微跟进一下时代,重新演绎一遍。
PyCodeObject
在Python中,一切都是对象,也就是PyObject
的子类。代码也是如此。Pyc文件实际上就是PyCodeObject
对象。因此我们先分析PyCodeObject
对象的结构,随后再涉及Pyc文件的二进制结构。
3.7.9
1 | // python3.7.9 Include/code.h |
3.8.10
1 | // python3.8.10 Include/code.h |
和3.7版本相比,多了几个成员。
3.9.10
3.9.10的结构体定义集成到了Include/cpython/code.h
里面了。
和3.8似乎没有变化,略去。
3.10.2
和3.9没有区别,略去。
参数解释
co_argcount
除了*args
以外的参数的个数(根据测试,实际上是不包括 *args
和 **kwargs
以及强制关键字参数。)
1 | # 在 python3.8.10上测试 |
co_kwonlyargcount
仅能通过关键字传参的形参数量。
1 | def test(a, b, c, d=1, e=2, *args, f=3, g, h=4, **kwargs): |
f
,g
,h
这三个是必须明确关键字的,因为在*args
的后面。
1 | >>> def a(a=1,b=2,c=3): |
而如果没有*args
的阻挡,即使像这样设置了一个默认传参,co_kwonlyargcount
还是0。
1 | >>> a() |
co_nlocals
函数栈上的变量个数。
1 | def a(a): |
函数形参和内部声明的变量都算在栈上的变量,所以一共有3个。
co_stacksize
一个整数,代表函数会使用的最大栈空间。
co_flags
一些声明函数特征的宏信息,以CO_
开头。
1 | /* Masks for co_flags above */ |
了解即可。
co_firstlineno
代码对象的第一行位于所在文件的行号。
co_code
该函数的二进制字节码。
1 | b.co_code |
co_constants
1 | b.co_consts |
用到的常量。
co_names
用到的函数名字符串。
1 | def a(a="str1"): |
通过这个可以了解函数里面使用到的函数和方法。
co_varnames
局部变量名列表
1 | >>> b.co_varnames |
co_freevars
元组里面存储着所有被函数使用的在闭包作用域中定义的变量名。
co_cellvars
元组里面存储着所有被嵌套函数用到的变量名。
1 | def f(a, b): |
co_filename
函数所在文件名
co_name
函数名
区别于co_names
。
co_lnotab
字节码指令和行号的对应关系
1 | b.co_lnotab |
co_posonlyargcount
(3.8及以上)
number of positional only arguments
序列化(python3.7.9)
PyCodeObject
的序列化在Python/marshal.c
中实现。
1 | static void |
通过这个核心函数,能够得知对象中哪些成员将会写入pyc文件。
Opcode(3.7.9)
在opcode.h
中定义
版本越高,会出现更多的新opcode。后面再跟进。
1 |
TYPE宏
在marshal.c
中定义。这些宏在编译生成pyc文件的时候会用上,用于记录每种对象的类型。占用一个字节。
1 |
|
Pyc文件结构
- 魔数,对应相应python版本
- 修改时间
- 文件大小
PyCodeObject
字节码
但这是老早以前的分析文章得出的结论了。
下面准备找几个例子,从不同版本进行分析。
示例
1 | # test.py |
使用代码
1 | python3 -m py_compile .\test.py |
进行编译。
再使用pydisasm
反汇编。
3.7.9分析(x64解释器)
给出编译后字节码:
1 | b'B\r\r\n\x00\x00\x00\x00I\xa6\x04b[\x00\x00\x00\xe3\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00@\x00\x00\x00s\x12\x00\x00\x00d\x00Z\x00d\x05d\x02d\x03\x84\x01Z\x01d\x04S\x00)\x06Z\x05Hello\xe9\x03\x00\x00\x00c\x02\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x03\x00\x00\x00G\x00\x00\x00s\x1a\x00\x00\x00d\x01}\x03t\x00|\x00|\x01\x83\x02\x01\x00t\x00|\x02\x83\x01\x01\x00d\x00S\x00)\x02NiR\xbf\x01\x00)\x01\xda\x05print)\x04\xda\x01a\xda\x01b\xda\x04args\xda\x01c\xa9\x00r\x07\x00\x00\x00\xfa\t.\\test.py\xda\x04func\x04\x00\x00\x00s\x06\x00\x00\x00\x00\x01\x04\x01\n\x01r\t\x00\x00\x00N)\x01r\x01\x00\x00\x00)\x02\xda\x01sr\t\x00\x00\x00r\x07\x00\x00\x00r\x07\x00\x00\x00r\x07\x00\x00\x00r\x08\x00\x00\x00\xda\x08<module>\x02\x00\x00\x00s\x02\x00\x00\x00\x04\x02' |
根据https://nowave.it/python-bytecode-analysis-1.html所述,一般pyc3.7结构体如下
结构
魔数 | 0-3 |
位域 | 4-7 |
时间戳 | 8-11 |
文件大小 | 12-15 |
Marshalled code object | 16- |
魔数
前4字节是
1 | 420D0D0A |
0D0A
似乎不用管,420D
是小端序,实际上是0x0D42,即十进制的3394。根据pydisasm
得到的信息,3394对应3.7版本。
1 | # Python bytecode 3.7 (3394) |
由此可以发现小版本是无法得知的,只能知道大版本是python37。
在解释其中得知魔数:
1 | import importlib |
https://github.com/google/pytype/blob/main/pytype/pyc/magic.py
这个google的脚本给出了到现在所有的的python魔数。
1 | # These constants are from Python-3.x.x/Lib/importlib/_bootstrap_external.py |
位域
1 | 00000000 |
https://www.python.org/dev/peps/pep-0552/
python37及以下的位域似乎一直保持是0。以后可能会用上。
时间戳
4字节
1 | 49A60462 |
小端序,实际上是0x6204A649
,即十进制的1644471881,这个是秒数。使用
1 | import time |
可以得到具体的年月日时分秒。
pydisasm
信息
1 | # Timestamp in code: 1644471881 (2022-02-10 13:44:41) |
源码大小
1 | 5B00000000 |
小端序,即0x5B
,91字节大小。
是源码文件大小,不是编译后大小!
pydisasm
信息
1 | # Source code size mod 2**32: 91 bytes |
PyCodeObject
剩下的就是Marshalled Code Object了。使用以下代码加载。
1 | import marshal |
有意思的是,即使一个py文件里面其实有各种各样的PyObject
,比如数组,字符串,函数等,但是在序列化的时候,这整个文件(也就是一个默认模块)都会被序列化成一个PyCodeObject
,也就是代码对象。
尝试着打印一下这个对象的一些数据。
看一下Code Object里面写了啥。
1 | else if (PyCode_Check(v)) { |
然后看一下二进制文件:
(通过分析pycdas
反汇编器的逻辑来得到具体结构体,后面补充会谈)
1 | 42 0d 0d 0a // 版本魔数 |
其中部分相关的代码
1 | // marshal.c |
由于分析marshal.c代码和py_compile.py有些复杂,所以最后选择直接分析
pycdc
反汇编工具的源码。在后面补充部分谈到了。
二进制文件已经被我手动排版了。网上的几个反汇编工具和python原生的pydisasm都能完美的反汇编,但是和二进制文件的对应关系还不是很清晰。
补充
学习了一下pycdc
的思路。
其中的pycdas
是反汇编器。
pyc文件结构
1 | void PycModule::loadFromFile(const char* filename) |
由此可以看出:
- 位域是python3.7往上出现的
- 如果位域第一位为1,则会多4个字节的checksum
- 文件大小在python3.3以上添加。
关于魔数0xE3
1 | PycRef<PycObject> LoadObject(PycData* stream, PycModule* mod) |
0xE3和0x7F相与得到0x63,即’c’,是TYPE_CODE
同时0xE3 == 0x80 | 0x63
,所以 还有FLAG_REF
于是便会mod->refObject(obj);
1 | void refObject(PycRef<PycObject> str) { m_refs.push_back(str); } |
PyCodeObject结构
上文调用了obj->load(stream, mod);
即这个函数。
1 | void PycCode::load(PycData* stream, PycModule* mod) |
成员 | 大小 | 注意 |
---|---|---|
co_argcounnt | 4 | |
co_posonlyargcount | 4 | >=3.8 |
co_kwonlyargcount | 4 | |
co_nlocals | 4 | |
co_stacksize | 4 | |
co_flags | 4 | |
co_code | String | |
co_consts | Sequence | |
co_names | Sequence | |
co_varnames | Sequence | |
co_freevars | Sequence | |
co_cellvars | (obref)Sequence | 示例中是TYPE_OBREF,应该是一个对Sequence的引用,类似指针 |
co_filename | String | |
co_name | String | |
co_firstlineno | 4 | |
co_lnotab | String |
注意:
- co_code也作为TYPE_STRING类型存在,因为本质上属于是字节码。
PyStringObject结构
先给出pycdas
的源码
1 | /* PycString */ |
TYPE_STRING
's'
通常用于字节码对象
大小 | ||
---|---|---|
TYPE_STRING(‘s’) | 1 | |
length | 4 | |
buffer | length(或者length*2,取决于是不是utf8,通过&0x80判断) |
TYPE_SHORT_ASCII_INTERNED
'Z'
TYPE_SHORT_ASCII_INTERNED(‘Z’) | ||
length | 1 | |
buffer | length or length*2(utf8) |
TYPE_SHORT_ASCII
TYPE_SHORT_ASCII(‘z’) | 1 | |
length | 1 | |
buffer | length or length*2(utf8) |
PyTupleObject(Small Tuple)
1 | /* PycTuple */ |
TYPE_SMALL_TUPLE(‘)’) | ||
size | 1 | Small Tuple,所以是1 |
values[] | 序列内每个对象的大小之和 |
PyIntObject
32位
1 | /* PycInt */ |
TYPE_OBREF
1 | PycRef<PycObject> LoadObject(PycData* stream, PycModule* mod) |
TYPE_OBREF(‘r’) | 1 | |
index | 4 |
上面有FLAG_REF
标识的对象都会在PycModule
对象下的m_ref
列表中留下引用。
1 | void refObject(PycRef<PycObject> str) { m_refs.push_back(str); } |
1 | PycRef<PycObject> PycModule::getRef(int ref) const |
getRef
函数则会通过索引来取出对象引用。
总结
这个博客是便分析便写的,所以中途遭遇了不少挫折,尤其是一个字节一个字节分析二进制文件的时候,对于每个字节意思的理解。最后正向看python编译源码的路子还是失败了,选择了走别人的老路——也就是看pycdc
反汇编器的代码。
下一个博客,开始谈谈python汇编指令。
参考
Python 中的代码对象 code object 与 code 属性_团子大圆帅的博客-CSDN博客___code__
https://nowave.it/python-bytecode-analysis-1.html
https://www.cnblogs.com/lanzhi/p/6468567.html
- 本文作者: Taardis
- 本文链接: https://taardisaa.github.io/2022/02/09/Pyc Reverse 1/
- 版权声明: 本博客所有文章除特别声明外,均采用 Apache License 2.0 许可协议。转载请注明出处!